// Copyright (c) 2013, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import "dart:async";
import "dart:io";
import "dart:isolate";

import "package:expect/async_helper.dart";
import "package:expect/expect.dart";
import "package:path/path.dart";

void testWatchCreateFile() {
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  var file = new File(join(dir.path, 'file'));

  var watcher = dir.watch();

  asyncStart();
  var sub;
  sub = watcher.listen(
    (event) {
      if (event is FileSystemCreateEvent && event.path.endsWith('file')) {
        Expect.isFalse(event.isDirectory);
        asyncEnd();
        sub.cancel();
        dir.deleteSync(recursive: true);
      }
    },
    onError: (e) {
      dir.deleteSync(recursive: true);
      throw e;
    },
  );

  file.createSync();
}

void testWatchCreateDir() {
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  var subdir = new Directory(join(dir.path, 'dir'));

  var watcher = dir.watch();

  asyncStart();
  var sub;
  sub = watcher.listen(
    (event) {
      if (event is FileSystemCreateEvent && event.path.endsWith('dir')) {
        Expect.isTrue(event.isDirectory);
        asyncEnd();
        sub.cancel();
        dir.deleteSync(recursive: true);
      }
    },
    onError: (e) {
      dir.deleteSync(recursive: true);
      throw e;
    },
  );

  subdir.createSync();
}

void testWatchModifyFile() {
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  var file = new File(join(dir.path, 'file'));
  file.createSync();

  var watcher = dir.watch();

  asyncStart();
  var sub;
  sub = watcher.listen(
    (event) {
      if (event is FileSystemModifyEvent) {
        Expect.isTrue(event.path.endsWith('file'));
        sub.cancel();
        asyncEnd();
        dir.deleteSync(recursive: true);
      }
    },
    onError: (e) {
      dir.deleteSync(recursive: true);
      throw e;
    },
  );

  file.writeAsStringSync('a');
}

void testWatchTruncateFile() {
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  var file = new File(join(dir.path, 'file'));
  file.writeAsStringSync('ab');
  var fileHandle = file.openSync(mode: FileMode.append);

  var watcher = dir.watch();

  asyncStart();
  var sub;
  sub = watcher.listen(
    (event) {
      if (event is FileSystemModifyEvent) {
        Expect.isTrue(event.path.endsWith('file'));
        Expect.isTrue(event.contentChanged);
        sub.cancel();
        asyncEnd();
        fileHandle.closeSync();
        dir.deleteSync(recursive: true);
      }
    },
    onError: (e) {
      dir.deleteSync(recursive: true);
      throw e;
    },
  );

  fileHandle.truncateSync(1);
}

void testWatchMoveFile() {
  // Mac OS doesn't report move events.
  if (Platform.isMacOS) return;
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  var file = new File(join(dir.path, 'file'));
  file.createSync();

  var watcher = dir.watch();

  asyncStart();
  var sub;
  sub = watcher.listen(
    (event) {
      if (event is FileSystemMoveEvent) {
        Expect.isTrue(event.path.endsWith('file'));
        final destination = event.destination;
        if (destination != null) {
          Expect.isTrue(destination.endsWith('file2'));
        }
        sub.cancel();
        asyncEnd();
        dir.deleteSync(recursive: true);
      }
    },
    onError: (e) {
      dir.deleteSync(recursive: true);
      throw e;
    },
  );

  file.renameSync(join(dir.path, 'file2'));
}

void testWatchDeleteFile() {
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  var file = new File(join(dir.path, 'file'));
  file.createSync();

  var watcher = dir.watch();

  asyncStart();
  var sub;
  sub = watcher.listen(
    (event) {
      if (event is FileSystemDeleteEvent) {
        Expect.isTrue(event.path.endsWith('file'));
        sub.cancel();
        asyncEnd();
        dir.deleteSync(recursive: true);
      }
    },
    onError: (e) {
      dir.deleteSync(recursive: true);
      throw e;
    },
  );

  file.deleteSync();
}

void testWatchDeleteDir() {
  // Windows keeps the directory handle open, even though it's deleted. It'll
  // be flushed completely, once the watcher is closed as well.
  if (Platform.isWindows) return;
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  var watcher = dir.watch(events: 0);

  asyncStart();
  watcher.listen(
    (event) {
      if (event is FileSystemDeleteEvent) {
        Expect.isTrue(event.path == dir.path);
      }
    },
    onDone: () {
      asyncEnd();
    },
  );

  dir.deleteSync();
}

void testWatchOnlyModifyFile() {
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  var file = new File(join(dir.path, 'file'));

  var watcher = dir.watch(events: FileSystemEvent.modify);

  asyncStart();
  var sub;
  sub = watcher.listen(
    (event) {
      Expect.isTrue(event is FileSystemModifyEvent);
      Expect.isTrue(event.path.endsWith('file'));
      sub.cancel();
      asyncEnd();
      dir.deleteSync(recursive: true);
    },
    onError: (e) {
      dir.deleteSync(recursive: true);
      throw e;
    },
  );

  file.createSync();
  file.writeAsStringSync('a');
}

enum FileState { none, created, modified, moved, deleted }

void testMultipleEvents() async {
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  var file = new File(join(dir.path, 'file'));
  var file2 = new File(join(dir.path, 'file2'));

  final fileDeletionObserved = Completer<void>();
  var watcher = dir.watch();

  asyncStart();
  final state = <String, FileState>{};
  var seenMove = false;
  var sub;

  void updateState(FileSystemEvent event, String path, FileState newState) {
    final currentState = state[path] ?? FileState.none;

    // We expect that states change monotonically (e.g. we don't get creation
    // after deletion).
    Expect.isTrue(
      newState.index >= currentState.index,
      'unexpected state transition $currentState -> $newState ($event)',
    );

    if (newState == FileState.moved) {
      // On Windows and Linux rename should generate a move event.
      updateState(event, path, FileState.deleted);
      updateState(
        event,
        (event as FileSystemMoveEvent).destination!,
        FileState.created,
      );
      seenMove = true;
      return;
    } else if (newState == FileState.deleted) {
      // We expect to observe creation or modification before deletion.
      Expect.isTrue(
        currentState.index <= FileState.modified.index,
        'file creation not observed',
      );

      if (event.path.endsWith('file')) {
        fileDeletionObserved.complete();
      } else if (event.path.endsWith('file2')) {
        sub.cancel();
        asyncEnd();
        dir.deleteSync();
      }
    }
    state[event.path] = newState;
  }

  sub = watcher.listen((event) {
    updateState(event, event.path, switch (event.type) {
      FileSystemEvent.create => FileState.created,
      FileSystemEvent.modify => FileState.modified,
      FileSystemEvent.move => FileState.moved,
      FileSystemEvent.delete => FileState.deleted,
      _ => throw 'Unexpected event: ${event}',
    });
  });

  file.createSync();
  file.writeAsStringSync('a');
  file.renameSync(file2.path);
  await fileDeletionObserved.future;
  Expect.isTrue(Platform.isMacOS || seenMove);
  file2.deleteSync();
}

void testWatchRecursive() {
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  if (Platform.isLinux) {
    Expect.throws(() => dir.watch(recursive: true));
    return;
  }
  var dir2 = new Directory(join(dir.path, 'dir'));
  dir2.createSync();
  var file = new File(join(dir.path, 'dir/file'));

  var watcher = dir.watch(recursive: true);

  asyncStart();
  var sub;
  sub = watcher.listen(
    (event) {
      if (event.path.endsWith('file')) {
        sub.cancel();
        asyncEnd();
        dir.deleteSync(recursive: true);
      }
    },
    onError: (e) {
      dir.deleteSync(recursive: true);
      throw e;
    },
  );

  file.createSync();
}

void testWatchNonRecursive() {
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  var dir2 = new Directory(join(dir.path, 'dir'));
  dir2.createSync();
  var file = new File(join(dir.path, 'dir/file'));

  var watcher = dir.watch(recursive: false);

  asyncStart();
  var sub;
  sub = watcher.listen(
    (event) {
      if (event.path.endsWith('file')) {
        throw "File change event not expected";
      }
    },
    onError: (e) {
      dir.deleteSync(recursive: true);
      throw e;
    },
  );

  file.createSync();

  new Timer(const Duration(milliseconds: 300), () {
    sub.cancel();
    asyncEnd();
    dir.deleteSync(recursive: true);
  });
}

void testWatchNonExisting() {
  // MacOS allows listening on non-existing paths.
  if (Platform.isMacOS) return;
  asyncStart();
  new Directory('__some_none_existing_dir__').watch().listen(
    (_) {
      Expect.fail('unexpected error');
    },
    onError: (e) {
      asyncEnd();
      Expect.isTrue(e is PathNotFoundException);
    },
  );
}

void testWatchMoveSelf() {
  // Windows keeps the directory handle open, even though it's deleted. It'll
  // be flushed completely, once the watcher is closed as well.
  if (Platform.isWindows) return;
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');
  var dir2 = new Directory(join(dir.path, 'dir'))..createSync();

  var watcher = dir2.watch();

  asyncStart();
  bool gotDelete = false;
  watcher.listen(
    (event) {
      if (event is FileSystemDeleteEvent) {
        Expect.isTrue(event.path.endsWith('dir'));
        gotDelete = true;
      }
    },
    onDone: () {
      Expect.isTrue(gotDelete);
      dir.deleteSync(recursive: true);
      asyncEnd();
    },
  );

  dir2.renameSync(join(dir.path, 'new_dir'));
}

testWatchConsistentModifiedFile() async {
  // When file modification starts before the watcher listen() is called and the first event
  // happens in a very short period of time the modifying event will be missed before the
  // stream listen has been set up and the watcher will hang forever.
  // Bug: https://github.com/dart-lang/sdk/issues/37233
  // Bug: https://github.com/dart-lang/sdk/issues/37909
  asyncStart();
  ReceivePort receivePort = ReceivePort();
  Completer<bool> exiting = Completer<bool>();

  late Directory dir;
  Completer<bool> modificationEventReceived = Completer<bool>();

  late StreamSubscription receiverSubscription;
  late SendPort workerSendPort;
  receiverSubscription = receivePort.listen((object) async {
    if (object == 'modification_started') {
      var watcher = dir.watch();
      var subscription;
      // Wait for event and check the type
      subscription = watcher.listen((data) async {
        if (data is FileSystemModifyEvent) {
          Expect.isTrue(data.path.endsWith('file'));
          await subscription.cancel();
          modificationEventReceived.complete(true);
        }
      });
      return;
    }
    if (object == 'end') {
      await receiverSubscription.cancel();
      exiting.complete(true);
      return;
    }
    // init event
    workerSendPort = object[0];
    dir = new Directory(object[1]);
  });

  Completer<bool> workerExitedCompleter = Completer();
  RawReceivePort exitReceivePort = RawReceivePort((object) {
    workerExitedCompleter.complete(true);
  });
  RawReceivePort errorReceivePort = RawReceivePort((object) {
    print('worker errored: $object');
  });
  Isolate isolate = await Isolate.spawn(
    modifyFiles,
    receivePort.sendPort,
    onExit: exitReceivePort.sendPort,
    onError: errorReceivePort.sendPort,
  );

  await modificationEventReceived.future;
  workerSendPort.send('end');

  await exiting.future;
  await workerExitedCompleter.future;
  exitReceivePort.close();
  errorReceivePort.close();
  // Stop modifier isolate
  isolate.kill();
  asyncEnd();
}

void modifyFiles(SendPort sendPort) async {
  // Send sendPort back to listen for modification signal.
  ReceivePort receivePort = ReceivePort();
  var dir = Directory.systemTemp.createTempSync('dart_file_system_watcher');

  // Create file within the directory and keep modifying.
  var file = new File(join(dir.path, 'file'));
  file.createSync();
  bool done = false;
  var subscription;
  subscription = receivePort.listen((object) async {
    if (object == 'end') {
      await subscription.cancel();
      done = true;
    }
  });
  sendPort.send([receivePort.sendPort, dir.path]);
  bool notificationSent = false;
  while (!done) {
    // Start modifying the file continuously before watcher start watching.
    for (int i = 0; i < 100; i++) {
      file.writeAsStringSync('a');
    }
    if (!notificationSent) {
      sendPort.send('modification_started');
      notificationSent = true;
    }
    await Future.delayed(Duration());
  }
  // Clean up the directory and files
  dir.deleteSync(recursive: true);
  sendPort.send('end');
}

testWatchOverflow() async {
  // When underlying buffer for ReadDirectoryChangesW overflows(on Windows),
  // it will send an exception to Stream which has been listened.
  // Bug: https://github.com/dart-lang/sdk/issues/37233
  asyncStart();
  ReceivePort receivePort = ReceivePort();
  Completer<bool> exiting = Completer<bool>();

  Directory dir = Directory.systemTemp.createTempSync(
    'dart_file_system_watcher',
  );
  var file = new File(join(dir.path, 'file'));
  file.createSync();

  Isolate isolate = await Isolate.spawn(
    watcher,
    receivePort.sendPort,
    paused: true,
  );

  var subscription;
  subscription = receivePort.listen((object) async {
    if (object == 'end') {
      exiting.complete(true);
      subscription.cancel();
      // Clean up the directory and files
      dir.deleteSync(recursive: true);
      asyncEnd();
    } else if (object == 'start') {
      isolate.pause(isolate.pauseCapability);
      // Populate the buffer to overflows and check for exception
      for (int i = 0; i < 2000; i++) {
        file.writeAsStringSync('a');
      }
      isolate.resume(isolate.pauseCapability!);
    }
  });
  // Resume paused isolate to create watcher
  isolate.resume(isolate.pauseCapability!);

  await exiting.future;
  isolate.kill();
}

void watcher(SendPort sendPort) async {
  var watcher = Directory.systemTemp.watch(recursive: true);
  watcher.listen(
    (data) async {},
    onError: (error, stack) {
      sendPort.send('end');
    },
  );
  sendPort.send('start');
}

void main() {
  if (!FileSystemEntity.isWatchSupported) return;
  testWatchCreateFile();
  testWatchCreateDir();
  testWatchModifyFile();
  testWatchMoveFile();
  testWatchTruncateFile();
  testWatchDeleteFile();
  testWatchDeleteDir();
  testWatchOnlyModifyFile();
  testMultipleEvents();
  testWatchNonRecursive();
  testWatchNonExisting();
  testWatchMoveSelf();
  testWatchConsistentModifiedFile();
  if (Platform.isWindows) testWatchOverflow();
}
